浅析kotlin协程、挂起和非阻塞式挂起

您所在的位置:网站首页 线程挂起 函数 浅析kotlin协程、挂起和非阻塞式挂起

浅析kotlin协程、挂起和非阻塞式挂起

2024-06-09 17:03| 来源: 网络整理| 查看: 265

浅析kotlin协程、挂起和非阻塞式挂起 一、协程是什么二、协程好在哪三、协程的使用协程怎么用什么时候用协程?协程基本使用协程并发处理对比 launch 与 async 两个函数。 四、协程的挂起挂起的本质suspend 的意义?怎么自定义 suspend 函数? 五、非阻塞式挂起六、Kotlin协程的三大疑问

协程官方文档:https://www.kotlincn.net/docs/reference/coroutines/coroutines-guide.html

知识点:

协程究竟是什么?协程好在哪里呢?协程具体怎么用?协程挂起是什么?协程非阻塞式挂起是什么? 一、协程是什么

Kotlin 官方文档说 本质上,协程是轻量级的线程。

// 启动大量的协程 repeat(100_000) { coroutineScope.launch { delay(5000L) println("launch---.---") } } // 启动大量的线程 repeat(100_000) { Thread { Thread.sleep(5000L) println("Thread:---.---") } } //使用线程池 val executors = Executors.newSingleThreadScheduledExecutor() val task = java.lang.Runnable { println("executors:---.---") } repeat(100_000) { executors.schedule(task, 1, TimeUnit.SECONDS) }

它启动了 10 万个协程,并且在 5 秒钟后,每个协程都输出一个点。 现在,尝试使用线程来实现。会发生什么?(很可能你的代码会产生某种内存不足的错误)

我们所有的代码都是跑在线程中的,而线程是跑在进程中的。协程没有直接和操作系统关联,它也是跑在线程中的,可以是单线程,也可以是多线程。单线程中的协程总的执行时间并不会比不用协程少。Android 系统上,如果在主线程进行网络请求,会抛出 NetworkOnMainThreadException,对于在主线程上的协程也不例外,这种场景使用协程还是要切线程的。

协程设计的初衷是为了解决并发问题,让 「协作式多任务」 实现起来更加方便。

协程就是 Kotlin 提供的一套线程封装的 API—线程框架,但并不是说协程就是为线程而生的。

协程与Executor、AsyncTask 在 Kotlin 中,协程的一个典型的使用场景就是线程控制。 就像 Java 中的 Executor 和 Android 中的 AsyncTask,Kotlin 中的协程也有对 Thread API 的封装,让我们可以在写代码时,不用关注多线程就能够很方便地写出并发操作。

我们可以用 Java 的 Executor 线程池来进行线程管理:

val executor = Executors.newCachedThreadPool() executor.execute({ ... })

用 Android 的 AsyncTask 来解决线程间通信:

object : AsyncTask;T0, T1, T2; { override fun doInBackground(vararg args: T0): String { ... } override fun onProgressUpdate(vararg args: T1) { ... } override fun onPostExecute(t3: T3) { ... } }

AsyncTask 是 Android 对线程池 Executor 的封装,但它的缺点也很明显:

需要处理很多回调,如果业务多则容易陷入「回调地狱」。硬是把业务拆分成了前台、中间更新、后台三个函数。

看到这里你很自然想到使用 RxJava 解决回调地狱,它确实可以很方便地解决上面的问题。 RxJava,准确来讲是 ReactiveX 在 Java 上的实现,是一种响应式程序框架,我们通过它提供的「Observable」的编程范式进行链式调用,可以很好地消除回调。

使用协程,同样可以像 Rx 那样有效地消除回调地狱,不过无论是设计理念,还是代码风格,两者是有很大区别的,协程在写法上和普通的顺序代码类似。

下面的例子是使用协程进行网络请求获取用户信息并显示到 UI 控件上:

launch({ val user = api.getUser() // 👈 网络请求(IO 线程) nameTv.text = user.name // 👈 更新 UI(主线程) })

launch 并不是一个顶层函数,它必须在一个对象中使用 launch 函数加上实现在 {} 中具体的逻辑,就构成了一个协程。

通常我们做网络请求,要不就传一个 callback,要不就是在 IO 线程里进行阻塞式的同步调用,而在这段代码中,上下两个语句分别工作在两个线程里,但写法上看起来和普通的单线程代码一样。

这里的 api.getUser 是一个挂起函数,所以能够保证 nameTv.text 的正确赋值,这就涉及到了协程中最著名的「非阻塞式挂起」。

这种**「用同步的方式写异步的代码」**看起来很方便吧,那么我们来看看协程具体好在哪 ·

二、协程好在哪

代码书写层面:

复杂的并发代码,变得简单且清晰(kotlin的语法优势)用同步的方式写异步的代码

先了解一下「闭包」这个概念,调用 Kotlin 协程中的 API,经常会用到闭包写法。 其实闭包并不是 Kotlin 中的新概念,在 Java 8 中就已经支持。

我们先以 Thread 为例,来看看什么是闭包:

Thread(object : Runnable { override fun run() { ... } }) // 使用闭包,简化为 Thread { ... }

形如 Thread {…} 这样的结构中 {} 就是一个闭包。

闭包原则 在 Kotlin 中有这样一个语法糖:当函数的最后一个参数是 lambda 表达式时,可以将 lambda 写在括号外。这就是它的闭包原则。

在这里需要一个类型为 Runnable 的参数,而 Runnable 是一个接口,且只定义了一个函数 run,这种情况满足了 Kotlin 的 SAM,可以转换成传递一个 lambda 表达式,因为是最后一个参数,根据闭包原则我们就可以直接写成 Thread {…} 的形式。

对于上文所使用的 launch 函数,可以通过闭包来进行简化 :

launch { val user = api.getUser() // 👈 网络请求(IO 线程) nameTv.text = user.name // 👈 更新 UI(主线程) } 三、协程的使用 协程怎么用

在项目中配置对 Kotlin 协程的支持 在 build.gradle 文件中增加对 Kotlin 协程的依赖:

buildscript { ... ext.kotlin_coroutines = '1.3.1' ... }

Module 下的 build.gradle :

dependencies { ... // 依赖协程核心库 implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines" // 依赖当前平台所对应的平台库 implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines" ... } Kotlin 协程是以官方扩展库的形式进行支持的。而且,我们所使用的「核心库」和 「平台库」的版本应该保持一致。核心库中包含的代码主要是协程的公共 API 部分。有了这一层公共代码,才使得协程在各个平台上的接口得到统一。平台库中包含的代码主要是协程框架在具体平台的具体实现方式。因为多线程在各个平台的实现方式是有所差异的。

完成了以上的准备工作就可以开始使用协程了。 ‘

什么时候用协程?

当你需要切线程或者指定线程的时候

withContext 这个函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行。

那么可以将上面的代码写成这样:

coroutineScope.launch(Dispatchers.Main) { // 👈 在 UI 线程开始 val image = withContext(Dispatchers.IO) { // 👈 切换到 IO 线程,并在执行完后切回 UI线程 getImage(imageId) // 👈 将会运行在 IO 线程 挂起函数 } avatarIv.setImageBitmap(image) // 👈 回到 UI 线程更新 UI }

当需要频繁地进行线程切换,这种写法的优势就会体现出来。可以参考下面的对比:

// 第一种写法 coroutineScope.launch(Dispatchers.IO) { ... launch(Dispatchers.Main){ ... launch(Dispatchers.IO) { ... launch(Dispatchers.Main) { ... } } } } // 通过第二种写法来实现相同的逻辑 coroutineScope.launch(Dispatchers.Main) { ... withContext(Dispatchers.IO) { ... } ... withContext(Dispatchers.IO) { ... } ... }

由于可以**“自动切回来”**,消除了并发代码在协作时的嵌套。由于消除了嵌套关系,我们甚至可以把 withContext 放进一个单独的函数里面:

launch(Dispatchers.Main) { // 👈 在 UI 线程开始 val image = getImage(imageId) avatarIv.setImageBitmap(image) // 👈 执行结束后,自动切换回 UI 线程 } // 👇 suspend fun getImage(imageId: Int) = withContext(Dispatchers.IO) { ... }

这就是之前说的用同步的方式写异步的代码了。

协程基本使用

前面提到,launch 函数不是顶层函数,是不能直接用的,可以使用下面三种方法来创建协程:

// 方法一,使用 runBlocking 顶层函数 runBlocking { getImage(imageId) } // 方法二,使用 GlobalScope 单例对象 // 可以直接调用 launch 开启协程 GlobalScope.launch { getImage(imageId) } // 方法三,自行通过 CoroutineContext 创建一个 CoroutineScope 对象 // 需要一个类型为 CoroutineContext 的参数 val coroutineScope = CoroutineScope(context) coroutineScope.launch { getImage(imageId) } 方法一常用于单元测试的场景,而开发中不会用这种方法,因为它是线程阻塞的。方法二和使用 runBlocking的区别在于不会阻塞线程。但在 Android 开发中同样不推荐这种用法,因为它的生命周期会和 app一致,且不能取消。方法三是比较推荐的使用方法,我们可以通过 context参数去管理和控制协程的生命周期(这里的 context 和 Android 里的不是一个东西,是一个更通用的概念,会有一个 Android平台的封装来配合使用)。 协程并发处理

协程最常用的功能是并发,而并发的典型场景就是多线程。可以使用 Dispatchers.IO 参数把任务切到 IO 线程执行:

coroutineScope.launch(Dispatchers.IO) { ... }

也可以使用 Dispatchers.Main 参数切换到主线程:

coroutineScope.launch(Dispatchers.Main) { ... }

异步请求的例子完整写出来是这样的:

coroutineScope.launch(Dispatchers.Main) { // 在主线程开启协程 val user = api.getUser() // IO 线程执行网络请求 nameTv.text = user.name // 主线程更新 UI }

而通过 Java 实现以上逻辑,我们通常需要这样写:

api.getUser(new Callback;User;() { @Override public void success(User user) { runOnUiThread(new Runnable() { @Override public void run() { nameTv.setText(user.name); } }) } @Override public void failure(Exception e) { ... } });

这种回调式的写法,打破了代码的顺序结构和完整性,读起来相当难受。

对于回调式的写法,如果并发场景再复杂一些,代码的嵌套可能会更多,这样的话维护起来就非常麻烦。 但如果你使用了 Kotlin 协程,多层网络请求只需要这么写:

coroutineScope.launch(Dispatchers.Main) { // 开始协程:主线程 val token = api.getToken() // 网络请求:IO 线程 val user = api.getUser(token) // 网络请求:IO 线程 nameTv.text = user.name // 更新 UI:主线程 }

如果遇到的场景是多个网络请求需要等待所有请求结束之后再对 UI 进行更新。 使用协程,可以直接把两个并行请求写成上下两行,最后再把结果进行合并即可:

coroutineScope.launch(Dispatchers.Main) { val avatar = async { api.getAvatar(user) } // 获取用户头像 val logo = async { api.getCompanyLogo(user) } // 获取用户logo val merged = suspendingMerge(avatar, logo) // 合并结果 show(merged) // 更新 UI }

可以看到,即便是比较复杂的并行网络请求,也能够通过协程写出结构清晰的代码。需要注意的是 suspendingMerge 并不是协程 API 中提供的方法,而是我们自定义的一个可「挂起」的结果合并方法。

这里,两个没有相关性的后台任务,因为用了协程,被安排得明明白白,互相之间配合得很好,也就是我们之前说的「协作式任务」。

让复杂的并发代码,写起来变得简单且清晰,是协程的优势。 ·

对比 launch 与 async 两个函数。

launch: launch 返回一个 Job 并且不附带任何结果值 async: 返回一个 Deferred, 一个轻量级的非阻塞 future,你可以使用 .await() 在一个延期的值上得到它的最终结果, 但是 Deferred 也是一个 Job,所以如果需要的话,你可以取消它。 惰性启动的 async: async(start = CoroutineStart.LAZY)只有结果通过 await 获取的时候或者在 Job 的 start 函数调用的时候协程才会启动

launch 与 async的相同点和不同点

相同点:它们都可以用来启动一个协程,返回的都是 Coroutine不同点:async 返回的 Coroutine 多实现了 Deferred 接口。

关于 Deferred ,意思就是延迟,也就是结果稍后才能拿到。 我们调用 Deferred.await() 就可以得到结果了。

avatar 和 logo 的类型可以声明为 Deferred ,通过 await 获取结果并且更新到 UI 上显示。

coroutineScope.launch(Dispatchers.Main) { // async 函数启动新的协程 val avatar: Deferred = async { api.getAvatar(user) } // 获取用户头像 val logo: Deferred = async { api.getCompanyLogo(user) } // 获取用户的logo // 获取返回值 show(avatar.await(), logo.await()) // 更新 UI } 四、协程的挂起

suspend关键字:suspend 是 Kotlin 协程最核心的关键字,它的中文意思是「暂停」或者「可挂起」。 并且这个『挂起』是非阻塞式的,它不会阻塞你当前的线程。」

挂起的本质

挂起的对象是协程。 launch ,async 或者其他函数创建的协程,在执行到某一个 suspend 函数的时候,这个协程会被「suspend」,也就是被挂起。

那此时又是从哪里挂起?从当前线程挂起。换句话说,就是这个协程从正在执行它的线程上脱离。

注意,不是这个协程停下来了!是脱离,当前线程不再管这个协程要去做什么了。 分别看看这两个互相脱离的线程和协程接下来将会发生什么事情:

线程: 协程的代码块中,线程执行到了 suspend 函数这里的时候,就暂时不再执行剩余的协程代码,跳出协程的代码块。

那线程接下来会做什么呢? 如果它是一个后台线程:

要么无事可做,被系统回收要么继续执行别的后台任务

跟 Java 线程池里的线程在工作结束之后是完全一样的:回收或者再利用。

如果这个线程它是 Android 的主线程,那它接下来就会继续回去工作:也就是一秒钟 60 次的界面刷新任务。

一个常见的场景是,获取一个图片,然后显示出来:

// 主线程中 GlobalScope.launch(Dispatchers.Main) { val image = suspendingGetImage(imageId) // 获取图片 avatarIv.setImageBitmap(image) // 显示出来 } suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) { ... }

这段执行在主线程的协程,它实质上会往你的主线程 post 一个 Runnable,这个 Runnable 就是你的协程代码:

handler.post { val image = suspendingGetImage(imageId) avatarIv.setImageBitmap(image) }

当这个协程被挂起的时候,就是主线程 post 的这个 Runnable 提前结束,然后继续执行它界面刷新的任务。

所以接下来,我们来看看协程这一边。

协程: 线程的代码在到达 suspend 函数的时候被掐断,接下来协程会从这个 suspend 函数开始继续往下执行,不过是在指定的线程。

谁指定的?是 suspend 函数指定的,比如我们这个例子中,函数内部的 withContext 传入的 Dispatchers.IO 所指定的 IO 线程。

Dispatchers 调度器,它可以将协程限制在一个特定的线程执行,或者将它分派到一个线程池,或者让它不受限制地运行。 常用的 Dispatchers ,有以下三种:

Dispatchers.Main:Android 中的主线程Dispatchers.IO:针对磁盘和网络 IO 进行了优化,适合 IO 密集型的任务,比如:读写文件,操作数据库以及网络请求Dispatchers.Default:适合 CPU 密集型的任务,比如计算

回到我们的协程,它从 suspend函数开始脱离启动它的线程,继续执行在 Dispatchers 所指定的 IO 线程。

紧接着在 suspend 函数执行完成之后,协程为我们做的最爽的事就来了:

会自动帮我们把线程再切回来。

这个「切回来」是什么意思?

我们的协程原本是运行在主线程的,当代码遇到 suspend 函数的时候,发生线程切换,根据 Dispatchers 切换到了 IO 线程;

当这个函数执行完毕后,线程又切了回来,「切回来」也就是协程会帮我再 post 一个 Runnable,让我剩下的代码继续回到主线程去执行。

我们从线程和协程的两个角度都分析完成后,终于可以对协程的「挂起」suspend 做一个解释:

协程在执行到有 suspend 标记的函数的时候,会被 suspend 也就是被挂起,而所谓的被挂起,就是切个线程;挂起函数在执行完成之后,协程会重新切回它原先的线程。

再简单来讲,在 Kotlin 中所谓的挂起,就是一个稍后会被自动切回来的线程调度操作。

这个「切回来」的动作,在 Kotlin 里叫做 resume,恢复。

通过刚才的分析我们知道:挂起之后是需要恢复。 而恢复这个功能是协程的,如果你不在协程里面调用,恢复这个功能没法实现,所以也就回答了这个问题:为什么挂起函数必须在协程或者另一个挂起函数里被调用。

所以,要求 suspend 函数只能在协程里或者另一个 suspend 函数里被调用,还是为了要让协程能够在 suspend 函数切换线程之后再切回来。

怎么就「挂起」了? 我们了解到了什么是「挂起」后,再接着看看这个「挂起」是怎么做到的。

先随便写一个自定义的 suspend 函数:

suspend fun suspendingPrint() { println(;Thread: ${Thread.currentThread().name};) } I/System.out: Thread: main

输出的结果还是在主线程。

为什么没切换线程?因为它不知道往哪切,需要我们告诉它。

对比之前例子中 suspendingGetImage 函数代码:

// 👇 suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) { ... }

我们可以发现不同之处其实在于 withContext 函数。

其实通过 withContext 源码可以知道,它本身就是一个挂起函数,它接收一个 Dispatcher 参数,依赖这个 Dispatcher 参数的指示,你的协程被挂起,然后切到别的线程。

所以这个 suspend,其实并不是起到把任何把协程挂起,或者说切换线程的作用。

真正挂起协程这件事,是 Kotlin 的协程框架帮我们做的。

所以我们想要自己写一个挂起函数,仅仅只加上 suspend 关键字是不行的,还需要函数内部直接或间接地调用到 Kotlin 协程框架自带的 suspend 函数才行。 ·

suspend 的意义?

这个 suspend 关键字,既然它并不是真正实现挂起,

它其实是一个提醒。

函数的创建者对函数的使用者的提醒:我是一个耗时函数,我被我的创建者用挂起的方式放在后台运行,所以请在协程里调用我。

// 👇 redundant suspend modifier suspend fun suspendingPrint() { println(;Thread: ${Thread.currentThread().name};) }

如果你创建一个 suspend 函数但它内部不包含真正的挂起逻辑,编译器会给你一个提醒:redundant suspend modifier,告诉你这个 suspend 是多余的。

因为你这个函数实质上并没有发生挂起,那你这个 suspend 关键字只有一个效果:就是限制这个函数只能在协程里被调用,如果在非协程的代码中调用,就会编译不通过。

所以,创建一个 suspend 函数,为了让它包含真正挂起的逻辑,要在它内部直接或间接调用 Kotlin 自带的 suspend 函数,你的这个 suspend 才是有意义的。

怎么自定义 suspend 函数?

什么时候需要自定义 suspend 函数? 如果你的某个函数比较耗时,也就是要等的操作,那就把它写成 suspend 函数。这就是原则。

耗时操作一般分为两类:

I/O 操作CPU 计算工作。

比如文件的读写、网络交互、图片的模糊处理,都是耗时的,通通可以把它们写进 suspend 函数里。

另外这个「耗时」还有一种特殊情况,就是这件事本身做起来并不慢,但它需要等待,比如 5 秒钟之后再做这个操作。这种也是 suspend 函数的应用场景。

具体该怎么写 给函数加上 suspend 关键字,然后在 withContext 把函数的内容包住就可以了。

提到用 withContext是因为它在挂起函数里功能最简单直接:把线程自动切走和切回。

当然并不是只有 withContext 这一个函数来辅助我们实现自定义的 suspend 函数,比如还有一个挂起函数叫 delay,它的作用是等待一段时间后再继续往下执行代码。

使用它就可以实现刚才提到的等待类型的耗时操作:

suspend fun suspendUntilDone() { while (!done) { delay(5) } }

·

五、非阻塞式挂起 协程只是在写法上「看起来阻塞」,其实是「非阻塞」的,因为在协程里面它做了很多工作,其中有一个就是帮我们切线程。写法上和单线程的阻塞式是一样的。

「非阻塞式挂起」,其实它有几个前提:并没有限定在一个线程里说这件事,因为挂起这件事,本来就是涉及到多线程。

在 Kotlin 里,协程就是基于线程来实现的一种更上层的工具 API,类似于 Java 自带的 Executor 系列 API 或者 Android 的 Handler 系列 API。

只不过呢,协程它不仅提供了方便的 API,在设计思想上是一个基于线程的上层框架,你可以理解为新造了一些概念用来帮助你更好地使用这些 API,仅此而已。

就像 ReactiveX 一样,为了让你更好地使用各种操作符 API,新造了 Observable 等概念。 ·

六、Kotlin协程的三大疑问

协程是什么?挂起是什么?挂起的非阻塞式是什么?

协程就是切线程。挂起就是可以自动切回来的切线程。挂起的非阻塞式指的是它能用看起来阻塞的代码写出非阻塞的操作。

·

写在最后,以上是学习了扔物线的Kotlin协程讲解之后,进行了简单的笔记记录,方便自己今后翻阅。

附上扔物线讲解连接: https://rengwuxian.com/kotlin-coroutines-1/ https://rengwuxian.com/kotlin-coroutines-2/ https://rengwuxian.com/kotlin-coroutines-3/



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3